import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';

import * as cheerio from 'cheerio';

import { encryptString } from '../dist/core/encryption.js';
import testAdapter from './test-adapter.js';
import { loadFixture } from './test-utils.js';

// Helper to create encryption key from test key string
async function createKeyFromString(keyString) {
	const binaryString = atob(keyString);
	const bytes = new Uint8Array(binaryString.length);
	for (let i = 0; i < binaryString.length; i++) {
		bytes[i] = binaryString.charCodeAt(i);
	}
	return await crypto.subtle.importKey('raw', bytes, { name: 'AES-GCM' }, false, [
		'encrypt',
		'decrypt',
	]);
}

describe('Server islands', () => {
	describe('SSR', () => {
		/** @type {import('./test-utils').Fixture} */
		let fixture;
		before(async () => {
			fixture = await loadFixture({
				root: './fixtures/server-islands/ssr',
				adapter: testAdapter(),
			});
		});

		describe('dev', () => {
			let devServer;

			before(async () => {
				process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=';
				devServer = await fixture.startDevServer();
			});

			after(async () => {
				await devServer.stop();
				delete process.env.ASTRO_KEY;
			});

			it('omits the islands HTML', async () => {
				const res = await fixture.fetch('/');
				assert.equal(res.status, 200);
				const html = await res.text();
				const $ = cheerio.load(html);
				const serverIslandEl = $('h2#island');
				assert.equal(serverIslandEl.length, 0);
			});

			it('HTML escapes scripts', async () => {
				const res = await fixture.fetch('/');
				assert.equal(res.status, 200);
				const html = await res.text();
				assert.equal(html.includes("</script><script>alert('xss')</script><!--"), false);
			});

			it('island is not indexed', async () => {
				const res = await fixture.fetch('/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						encryptedSlots: '',
					}),
				});
				assert.equal(res.headers.get('x-robots-tag'), 'noindex');
			});

			it('island can set headers', async () => {
				const res = await fixture.fetch('/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						encryptedSlots: '',
					}),
				});
				const works = res.headers.get('X-Works');
				assert.equal(works, 'true', 'able to set header from server island');
			});
			it('omits empty props from the query string', async () => {
				const res = await fixture.fetch('/empty-props');
				assert.equal(res.status, 200);
				const html = await res.text();
				const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
			});
			it('re-encrypts props on each request', async () => {
				const res = await fixture.fetch('/includeComponentWithProps/');
				assert.equal(res.status, 200);
				const html = await res.text();
				const fetchMatch = html.match(
					/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
				);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				const firstProps = fetchMatch[1];
				const secondRes = await fixture.fetch('/includeComponentWithProps/');
				assert.equal(secondRes.status, 200);
				const secondHtml = await secondRes.text();
				const secondFetchMatch = secondHtml.match(
					/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
				);
				assert.equal(secondFetchMatch.length, 2, 'should include props in the query string');
				assert.notEqual(
					secondFetchMatch[1],
					firstProps,
					'should re-encrypt props on each request with a different IV',
				);
			});

			it('rejects invalid props', async () => {
				const res = await fixture.fetch('/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						// not the expected value:
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE',
						encryptedSlots: '',
					}),
				});
				assert.equal(res.status, 400);
			});

			it('rejects plaintext slots', async () => {
				const res = await fixture.fetch('/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						slots: { xss: '<img src=x onerror=alert(0)>' },
					}),
				});
				assert.equal(res.status, 400, 'should reject unencrypted slots');
			});

			it('rejects plaintext slots with XSS payload via GET', async () => {
				const res = await fixture.fetch(
					'/_server-islands/Island?e=file&s=%7B%22xss%22%3A%22%3Cimg%20src%3Dx%20onerror%3Dalert(0)%3E%22%7D',
				);
				assert.equal(res.status, 400, 'should reject plaintext slots with XSS');
			});

			it('accepts encrypted slots via POST', async () => {
				const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
				const slotsToEncrypt = { content: '<p>Safe slot content</p>' };
				const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));

				const res = await fixture.fetch('/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						encryptedSlots: encryptedSlots,
					}),
				});
				assert.equal(res.status, 200, 'should accept encrypted slots');
			});

			it('rejects invalid encrypted slots via POST', async () => {
				const res = await fixture.fetch('/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						// hard-coded invalid encrypted slot value:
						encryptedSlots: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE',
					}),
				});
				assert.equal(res.status, 400, 'should reject invalid encrypted slots');
			});

			it('accepts encrypted slots with XSS payload via POST', async () => {
				const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
				const slotsToEncrypt = { xss: '<img src=x onerror=alert(0)>' };
				const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));

				const res = await fixture.fetch('/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						encryptedSlots: encryptedSlots,
					}),
				});
				assert.equal(
					res.status,
					200,
					'should accept even XSS in encrypted slots (safe when encrypted)',
				);
			});

			it('supports mdx', async () => {
				const res = await fixture.fetch('/test');
				assert.equal(res.status, 200);
				const html = await res.text();
				const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
			});

			it('supports fragments', async () => {
				const res = await fixture.fetch('/fragment');
				assert.equal(res.status, 200);
				const html = await res.text();
				const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
			});

			it('supports fragments with named slots', async () => {
				const res = await fixture.fetch('/fragment');
				assert.equal(res.status, 200);
				const html = await res.text();
				const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
			});
		});

		describe('prod', () => {
			before(async () => {
				process.env.ASTRO_KEY = 'eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=';
				await fixture.build();
			});

			after(async () => {
				delete process.env.ASTRO_KEY;
			});

			it('omits the islands HTML', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request('http://example.com/');
				const response = await app.render(request);
				const html = await response.text();

				const $ = cheerio.load(html);
				const serverIslandEl = $('h2#island');
				assert.equal(serverIslandEl.length, 0);

				const serverIslandScript = $('script[data-island-id]');
				assert.equal(serverIslandScript.length, 1, 'has the island script');
			});

			it('island is not indexed', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request('http://example.com/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						encryptedSlots: '',
					}),
					headers: {
						origin: 'http://example.com',
					},
				});
				const response = await app.render(request);
				assert.equal(response.headers.get('x-robots-tag'), 'noindex');
			});
			it('omits empty props from the query string', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request('http://example.com/empty-props');
				const response = await app.render(request);
				assert.equal(response.status, 200);
				const html = await response.text();
				const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
			});
			it('re-encrypts props on each request', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request('http://example.com/includeComponentWithProps/');
				const response = await app.render(request);
				assert.equal(response.status, 200);
				const html = await response.text();
				const fetchMatch = html.match(
					/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
				);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				const firstProps = fetchMatch[1];
				const secondRequest = new Request('http://example.com/includeComponentWithProps/');
				const secondResponse = await app.render(secondRequest);
				assert.equal(secondResponse.status, 200);
				const secondHtml = await secondResponse.text();
				const secondFetchMatch = secondHtml.match(
					/fetch\('\/_server-islands\/ComponentWithProps\?[^']*p=([^&']*)/,
				);
				assert.equal(secondFetchMatch.length, 2, 'should include props in the query string');
				assert.notEqual(
					secondFetchMatch[1],
					firstProps,
					'should re-encrypt props on each request with a different IV',
				);
			});

			it('rejects invalid props', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request('http://example.com/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						// not the expected value:
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE',
						encryptedSlots: '',
					}),
					headers: {
						origin: 'http://example.com',
					},
				});
				const response = await app.render(request);
				assert.equal(response.status, 400);
			});

			it('rejects plaintext slots', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request('http://example.com/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						slots: { xss: '<img src=x onerror=alert(0)>' },
					}),
					headers: {
						origin: 'http://example.com',
					},
				});
				const response = await app.render(request);
				assert.equal(response.status, 400, 'should reject unencrypted slots');
			});

			it('rejects plaintext slots with XSS payload via GET', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request(
					'http://example.com/_server-islands/Island?e=file&s=%7B%22xss%22%3A%22%3Cimg%20src%3Dx%20onerror%3Dalert(0)%3E%22%7D',
					{
						headers: {
							origin: 'http://example.com',
						},
					},
				);
				const response = await app.render(request);
				assert.equal(response.status, 400, 'should reject plaintext slots with XSS');
			});

			it('accepts encrypted slots via POST', async () => {
				const app = await fixture.loadTestAdapterApp();
				const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
				const slotsToEncrypt = { content: '<p>Safe slot content</p>' };
				const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));

				const request = new Request('http://example.com/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						encryptedSlots: encryptedSlots,
					}),
					headers: {
						origin: 'http://example.com',
					},
				});
				const response = await app.render(request);
				assert.equal(response.status, 200, 'should accept encrypted slots');
			});

			it('rejects invalid encrypted slots via POST', async () => {
				const app = await fixture.loadTestAdapterApp();

				const request = new Request('http://example.com/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						// hard-coded invalid encrypted slot value:
						encryptedSlots: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLE',
					}),
					headers: {
						origin: 'http://example.com',
					},
				});
				const response = await app.render(request);
				assert.equal(response.status, 400, 'should reject invalid encrypted slots');
			});

			it('accepts encrypted slots with XSS payload via POST', async () => {
				const app = await fixture.loadTestAdapterApp();
				const key = await createKeyFromString('eKBaVEuI7YjfanEXHuJe/pwZKKt3LkAHeMxvTU7aR0M=');
				const slotsToEncrypt = { xss: '<img src=x onerror=alert(0)>' };
				const encryptedSlots = await encryptString(key, JSON.stringify(slotsToEncrypt));

				const request = new Request('http://example.com/_server-islands/Island', {
					method: 'POST',
					body: JSON.stringify({
						componentExport: 'default',
						encryptedProps: 'FC8337AF072BE5B1641501E1r8mLIhmIME1AV7UO9XmW9OLD',
						encryptedSlots: encryptedSlots,
					}),
					headers: {
						origin: 'http://example.com',
					},
				});
				const response = await app.render(request);
				assert.equal(
					response.status,
					200,
					'should accept even XSS in encrypted slots (safe when encrypted)',
				);
			});

			it('supports mdx', async () => {
				const app = await fixture.loadTestAdapterApp();
				const request = new Request('http://example.com/test/');
				const res = await app.render(request);
				assert.equal(res.status, 200);
				const html = await res.text();
				const fetchMatch = html.match(/fetch\('\/_server-islands\/Island\?[^']*p=([^&']*)/);
				assert.equal(fetchMatch.length, 2, 'should include props in the query string');
				assert.equal(fetchMatch[1], '', 'should not include encrypted empty props');
			});
		});
	});

	describe('Hybrid mode', () => {
		/** @type {import('./test-utils').Fixture} */
		let fixture;
		before(async () => {
			fixture = await loadFixture({
				root: './fixtures/server-islands/hybrid',
			});
		});

		describe('build', () => {
			before(async () => {
				await fixture.build({
					adapter: testAdapter(),
				});
			});

			it('Omits the island HTML from the static HTML', async () => {
				let html = await fixture.readFile('/client/index.html');

				const $ = cheerio.load(html);
				const serverIslandEl = $('h2#island');
				assert.equal(serverIslandEl.length, 0);

				const serverIslandScript = $('script[data-island-id]');
				assert.equal(serverIslandScript.length, 2, 'has the island script');
			});

			it('includes the server island runtime script once', async () => {
				let html = await fixture.readFile('/client/index.html');

				const $ = cheerio.load(html);
				const serverIslandScript = $('script').filter((_, el) =>
					$(el).html().trim().startsWith('async function replaceServerIsland'),
				);
				assert.equal(
					serverIslandScript.length,
					1,
					'should include the server island runtime script once',
				);
			});
		});

		describe('with no adapter', () => {
			let devServer;

			it('Errors during the build', async () => {
				try {
					await fixture.build({
						adapter: undefined,
					});
					assert.equal(true, false, 'should not have succeeded');
				} catch (err) {
					assert.equal(err.title, 'Cannot use Server Islands without an adapter.');
				}
			});

			it('Errors during dev', async () => {
				devServer = await fixture.startDevServer();
				const res = await fixture.fetch('/');
				assert.equal(res.status, 500);
				const html = await res.text();
				const $ = cheerio.load(html);
				assert.equal($('title').text(), 'NoAdapterInstalledServerIslands');
			});
			after(() => {
				return devServer?.stop();
			});
		});
	});
});
